package com.gitee.mocoffee.hclient;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.Authenticator;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.Proxy;
import java.net.Socket;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.Charset;
import java.security.KeyStore;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import javax.net.ssl.SSLContext;

import org.apache.http.Header;
import org.apache.http.HeaderElement;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.NameValuePair;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.CookieStore;
import org.apache.http.client.CredentialsProvider;
import org.apache.http.client.ResponseHandler;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.entity.DeflateDecompressingEntity;
import org.apache.http.client.entity.GzipDecompressingEntity;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpDelete;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.client.methods.HttpOptions;
import org.apache.http.client.methods.HttpPatch;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.methods.HttpTrace;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.config.ConnectionConfig;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.config.SocketConfig;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.entity.BasicHttpEntity;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.BasicCookieStore;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.client.HttpClients;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.impl.cookie.BasicClientCookie;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.protocol.HttpContext;
import org.apache.http.ssl.SSLContextBuilder;
import org.apache.http.ssl.SSLContexts;
import org.apache.http.util.EntityUtils;

import com.google.common.base.Strings;
import com.google.common.net.HostAndPort;

import jodd.props.Props;
import lombok.extern.slf4j.Slf4j;

/**
 * @author lijia
 */
@Slf4j
public class Hclient {
    public static final Hclient me = new Hclient(new Properties(), null, null);

    public static final int DEFAULT_SOCKET_TIMEOUT_MILLIS = 300000;
    public static final int DEFAULT_BACKLOG_SIZE          = 2048;
    public static final int DEFAULT_SEND_BUF_SIZE         = 1024;
    public static final int DEFAULT_RECEIVE_BUF_SIZE      = 8192;
    public static final int DEFAULT_POOL_MAX              = 1024;
    public static final int DEFAULT_POOL_MAX_PER_SITE     = 512;
    public static final int DEFAULT_IDLE_SECS             = 60;

    public static final String KEY_SOCKET_TIMEOUT_MILLIS = "soTimeout";//读取超时
    public static final String KEY_BACKLOG_SIZE          = "backlog";//队列
    public static final String KEY_SEND_BUF_SIZE         = "sndBuf";//发送缓存
    public static final String KEY_RECEIVE_BUF_SIZE      = "rcvBuf";//接收缓存
    public static final String KEY_POOL_MAX              = "poolMax";//连接池总数
    public static final String KEY_POOL_MAX_PER_SITE     = "poolSite";//每个站点最大连接数
    public static final String KEY_IDLE_SECS             = "idle";//检测连接的时间间隔

    private static final String KEY_PROXY_TYPE = "PROXY_TYPE";
    private static final String KEY_PROXY      = "PROXY";
    private static final String KEY_PROXY_DNS  = "PROXY_DNS";

    private volatile CloseableHttpClient                pooledClient;
    private volatile PoolingHttpClientConnectionManager connectionManager;
    private volatile boolean                            closed = false;

    public Hclient(HclientConfig config, KeyStore keyStore, String keyPassword) {
        int socketTimeoutMillis = config != null && config.getSocketTimeoutMillis() != null ? config
                .getSocketTimeoutMillis() : DEFAULT_SOCKET_TIMEOUT_MILLIS;

        int backlog = config != null && config.getBacklog() != null ? config.getBacklog() : DEFAULT_BACKLOG_SIZE;

        int sendBuf = config != null && config.getSendBuf() != null ? config.getSendBuf() : DEFAULT_SEND_BUF_SIZE;

        int receiveBuf = config != null && config.getReceiveBuf() != null ? config
                .getReceiveBuf() : DEFAULT_RECEIVE_BUF_SIZE;

        int poolMax = config != null && config.getPoolMax() != null ? config.getPoolMax() : DEFAULT_POOL_MAX;

        int poolMaxPerSite = config != null && config.getPoolMaxPerSite() != null ? config
                .getPoolMaxPerSite() : DEFAULT_POOL_MAX_PER_SITE;

        int idleSecs = config != null && config.getIdleSecs() != null ? config.getIdleSecs() : DEFAULT_IDLE_SECS;

        SocketConfig soConfig = SocketConfig.custom()
                                            .setTcpNoDelay(true)
                                            .setSoTimeout(socketTimeoutMillis)
                                            .setSoReuseAddress(true)
                                            .setSoKeepAlive(true)
                                            .setBacklogSize(backlog)
                                            .setSndBufSize(sendBuf)
                                            .setRcvBufSize(receiveBuf)
                                            .build();

        HttpClientBuilder builder = HttpClients.custom();

        builder.evictIdleConnections(idleSecs, TimeUnit.SECONDS);

        // https协议支持
        SSLContext sslContext = null;
        try {
            SSLContextBuilder sslContextBuilder = SSLContexts.custom();
            sslContextBuilder = sslContextBuilder.loadTrustMaterial(null, (chain, authType) -> true);//不对服务端证书做校验
            if (keyStore != null && !Strings.isNullOrEmpty(keyPassword))
                sslContextBuilder = sslContextBuilder.loadKeyMaterial(keyStore, keyPassword.toCharArray());
            sslContext = sslContextBuilder.build();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        connectionManager = new PoolingHttpClientConnectionManager(
                RegistryBuilder.<ConnectionSocketFactory>create()
                        .register("http", new ProxiablePlainConnectionSocketFactory())
                        .register("https", new ProxiableSSLConnectionSocketFactory(sslContext))
                        .build()
        );
        connectionManager.setMaxTotal(poolMax);
        connectionManager.setDefaultMaxPerRoute(poolMaxPerSite);
        connectionManager.setDefaultSocketConfig(soConfig);
        connectionManager.setDefaultConnectionConfig(ConnectionConfig.DEFAULT);
        builder.setConnectionManager(connectionManager);

        // gzip,deflate支持
        builder.addInterceptorFirst((org.apache.http.HttpResponse response, HttpContext context) -> {
            HttpEntity entity = response.getEntity();
            if (entity != null) {
                Header ceheader = entity.getContentEncoding();
                if (ceheader != null) {
                    HeaderElement[] codecs = ceheader.getElements();
                    for (int i = 0; i < codecs.length; i++) {
                        if (codecs[i].getName().equalsIgnoreCase("gzip")) {
                            response.setEntity(new GzipDecompressingEntity(entity));
                            return;
                        } else if (codecs[i].getName().equalsIgnoreCase("deflate")) {
                            response.setEntity(new DeflateDecompressingEntity(entity));
                            return;
                        }
                    }
                }
            }
        });

        this.pooledClient = builder.build();

        Runtime.getRuntime().addShutdownHook(new Thread() {
            @Override
            public void run() {
                close();
            }
        });
    }

    private static HclientConfig transferFromProps(Props props) {
        if (props == null)
            return null;

        HclientConfig rst = new HclientConfig();
        rst.setSocketTimeoutMillis(props.getIntegerValue(KEY_SOCKET_TIMEOUT_MILLIS, DEFAULT_SOCKET_TIMEOUT_MILLIS));
        rst.setBacklog(props.getIntegerValue(KEY_BACKLOG_SIZE, DEFAULT_BACKLOG_SIZE));
        rst.setSendBuf(props.getIntegerValue(KEY_SEND_BUF_SIZE, DEFAULT_SEND_BUF_SIZE));
        rst.setReceiveBuf(props.getIntegerValue(KEY_RECEIVE_BUF_SIZE, DEFAULT_RECEIVE_BUF_SIZE));
        rst.setPoolMax(props.getIntegerValue(KEY_POOL_MAX, DEFAULT_POOL_MAX));
        rst.setPoolMaxPerSite(props.getIntegerValue(KEY_POOL_MAX_PER_SITE, DEFAULT_POOL_MAX_PER_SITE));
        rst.setIdleSecs(props.getIntegerValue(KEY_IDLE_SECS, DEFAULT_IDLE_SECS));
        return rst;
    }

    public Hclient(Props conf, KeyStore keyStore, String keyPassword) {
        this(transferFromProps(conf), keyStore, keyPassword);
    }

    public Hclient(Properties conf, KeyStore keyStore, String keyPassword) {
        this(conf == null ? null : Props.create().load(conf), keyStore, keyPassword);
    }

    private class ProxiablePlainConnectionSocketFactory extends PlainConnectionSocketFactory {
        @Override
        public Socket createSocket(HttpContext context) throws IOException {
            HostAndPort proxy = (HostAndPort) context.getAttribute(KEY_PROXY);
            if (proxy == null)
                return super.createSocket(context);
            else {
                Hrequest.ProxyType proxyType = (Hrequest.ProxyType) context.getAttribute(KEY_PROXY_TYPE);
                if (proxyType == Hrequest.ProxyType.SOCKS)
                    return new Socket(
                            new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(proxy.getHost(), proxy.getPort())));
                else
                    return super.createSocket(context);
            }
        }

        @Override
        public Socket connectSocket(int connectTimeout, Socket socket, HttpHost host, InetSocketAddress remoteAddress,
                                    InetSocketAddress localAddress, HttpContext context) throws IOException {
            HostAndPort proxy = (HostAndPort) context.getAttribute(KEY_PROXY);
            if (proxy != null) {
                Hrequest.ProxyType proxyType = (Hrequest.ProxyType) context.getAttribute(KEY_PROXY_TYPE);
                if (proxyType == Hrequest.ProxyType.SOCKS) {
                    if (true == (Boolean) context.getAttribute(KEY_PROXY_DNS)) {
                        remoteAddress = InetSocketAddress
                                .createUnresolved(host.getHostName(), host.getPort());
                    }
                }
            }

            return super.connectSocket(connectTimeout, socket, host, remoteAddress, localAddress, context);
        }
    }

    private class ProxiableSSLConnectionSocketFactory extends SSLConnectionSocketFactory {

        public ProxiableSSLConnectionSocketFactory(SSLContext sslContext) {
            super(sslContext);
        }

        @Override
        public Socket createSocket(HttpContext context) throws IOException {
            HostAndPort proxy = (HostAndPort) context.getAttribute(KEY_PROXY);
            if (proxy == null)
                return super.createSocket(context);
            else {
                Hrequest.ProxyType proxyType = (Hrequest.ProxyType) context.getAttribute(KEY_PROXY_TYPE);
                if (proxyType == Hrequest.ProxyType.SOCKS)
                    return new Socket(
                            new Proxy(Proxy.Type.SOCKS, new InetSocketAddress(proxy.getHost(), proxy.getPort())));
                else
                    return super.createSocket(context);
            }
        }

        @Override
        public Socket connectSocket(int connectTimeout, Socket socket, HttpHost host, InetSocketAddress remoteAddress,
                                    InetSocketAddress localAddress, HttpContext context) throws IOException {
            HostAndPort proxy = (HostAndPort) context.getAttribute(KEY_PROXY);
            if (proxy != null) {
                Hrequest.ProxyType proxyType = (Hrequest.ProxyType) context.getAttribute(KEY_PROXY_TYPE);
                if (proxyType == Hrequest.ProxyType.SOCKS) {
                    if (true == (Boolean) context.getAttribute(KEY_PROXY_DNS)) {
                        remoteAddress = InetSocketAddress
                                .createUnresolved(host.getHostName(), host.getPort());
                    }
                }
            }

            return super.connectSocket(connectTimeout, socket, host, remoteAddress, localAddress, context);
        }
    }

    private static class ThreadLocalSocksProxyAuthenticator extends Authenticator {
        private static final ThreadLocalSocksProxyAuthenticator  INSTANCE    = new ThreadLocalSocksProxyAuthenticator();
        private              ThreadLocal<PasswordAuthentication> credentials = null;

        private ThreadLocalSocksProxyAuthenticator() {
            this.credentials = new ThreadLocal<>();
            Authenticator.setDefault(this);
        }

        public void setCredentials(String user, String password) {
            credentials.set(new PasswordAuthentication(user, password.toCharArray()));
        }

        public void clearCredentials() {
            credentials.set(null);
        }

        @Override
        protected PasswordAuthentication getPasswordAuthentication() {
            return credentials.get();
        }
    }

    public void close() {
        if (!closed)
            try {
                pooledClient.close();
                connectionManager.shutdown();
                closed = true;
            } catch (IOException e) {
                e.printStackTrace();
            }
    }

    private URI buildGetURI(Hrequest request) throws URISyntaxException {
        Map<String, String> params = request.getParams();
        URIBuilder builder = new URIBuilder(request.getUrl());
        if (!params.isEmpty()) {
            params.forEach((key, value) -> {
                builder.addParameter(key, value);
            });
        }
        return builder.build();
    }

    private HttpEntity buildPostEntity(Hrequest request)
            throws UnsupportedEncodingException {
        String charset = request.getReqCharset();
        String mime = request.getMime();
        Map<String, String> params = request.getParams();
        String postString = request.getPostString();
        byte[] postBody = request.getPostBody();
        ContentType contentType = ContentType.create(mime, charset);
        HttpEntity entity = null;

        if (params != null && !params.isEmpty()) { // 优先使用参数列表填充表单来post
            List<NameValuePair> form = params.entrySet()
                                             .stream()
                                             .map(entry -> new BasicNameValuePair(entry.getKey(), entry.getValue()))
                                             .collect(Collectors.toList());
            entity = new UrlEncodedFormEntity(form, charset);
        } else if (!Strings.isNullOrEmpty(postString)) {// 如果参数列表为空，则使用字符串请求体
            entity = new StringEntity(postString, contentType);
        } else if (postBody != null && postBody.length > 0) {
            entity = new ByteArrayEntity(postBody);
        } else {
            entity = new BasicHttpEntity();
        }

        return entity;
    }

    public <T> Hresponse<T> request(Hrequest request,
                                    ResponseHandler<T> responseHandler) throws URISyntaxException, ClientProtocolException, IOException {
        Hrequest.Hmethod method = request.getMethod();
        String url = request.getUrl();

        final HttpRequestBase httpRequest;
        switch (method) {
            case GET:
                httpRequest = new HttpGet(buildGetURI(request));
                break;
            case POST:
                httpRequest = new HttpPost(url);
                ((HttpPost) httpRequest).setEntity(buildPostEntity(request));
                break;
            case PUT:
                httpRequest = new HttpPut(url);
                ((HttpPut) httpRequest).setEntity(buildPostEntity(request));
                break;
            case PATCH:
                httpRequest = new HttpPatch(url);
                ((HttpPatch) httpRequest).setEntity(buildPostEntity(request));
                break;
            case DELETE:
                httpRequest = new HttpDelete(buildGetURI(request));
                break;
            case HEAD:
                httpRequest = new HttpHead(buildGetURI(request));
                break;
            case OPTIONS:
                httpRequest = new HttpOptions(buildGetURI(request));
                break;
            case TRACE:
                httpRequest = new HttpTrace(buildGetURI(request));
                break;
            default:
                httpRequest = null;
        }

        HttpClientContext context = HttpClientContext.create();

        // 设置header
        request.getHeaders().forEach((key, value) -> {
            httpRequest.addHeader(key, value);
        });

        // 设置cookie
        CookieStore cookieStore = new BasicCookieStore();
        context.setCookieStore(cookieStore);
        request.getCookies().forEach((key, value) -> {
            cookieStore.addCookie(new BasicClientCookie(key, value));
        });

        // 设置请求参数
        RequestConfig.Builder builder = RequestConfig.custom().setRedirectsEnabled(request.isFollowRedirect());

        int connTimeout = request.getConnTimeout();
        int readTimeout = request.getReadTimeout();
        if (connTimeout > 0) {
            builder.setConnectTimeout(connTimeout).setConnectionRequestTimeout(connTimeout);
        }
        if (readTimeout > 0) {
            builder.setSocketTimeout(readTimeout);
        }

        boolean clearCredentialTag = false;
        if (request.needProxy()) {
            HostAndPort proxy = request.getProxy();
            context.setAttribute(KEY_PROXY, proxy);
            context.setAttribute(KEY_PROXY_TYPE, request.getProxyType());
            context.setAttribute(KEY_PROXY_DNS, request.isProxyDNS());

            if (request.getProxyType() == Hrequest.ProxyType.HTTP) {
                builder.setProxy(new HttpHost(proxy.getHost(), proxy.getPort()));
                if (request.needProxyAuth()) {
                    CredentialsProvider credsProvider = new BasicCredentialsProvider();
                    UsernamePasswordCredentials usernamePasswordCredentials = new UsernamePasswordCredentials(
                            request.getProxyCredential()[0],
                            request.getProxyCredential()[1]);
                    credsProvider.setCredentials(new AuthScope(proxy.getHost(), proxy.getPort()),
                                                 usernamePasswordCredentials);
                    context.setCredentialsProvider(credsProvider);
                }
            } else if (request.needProxyAuth()) {
                ThreadLocalSocksProxyAuthenticator.INSTANCE.setCredentials(request.getProxyCredential()[0],
                                                                           request.getProxyCredential()[1]);
                clearCredentialTag = true;
            }
        }

        httpRequest.setConfig(builder.build());

        // 重试发送请求
        CloseableHttpResponse response = null;
        int retryCount = 0;
        boolean stopRetry = false;
        while (!stopRetry) {
            try {
                response = pooledClient.execute(httpRequest, context);
                stopRetry = true;

                int statusCode = response.getStatusLine().getStatusCode();

                Map<String, String> headers = Stream.of(response.getAllHeaders())
                                                    .collect(Collectors.toMap(Header::getName, Header::getValue,
                                                                              (s, a) -> s + ";" + a));

                Map<String, String> cookies = cookieStore.getCookies().stream()
                                                         .collect(
                                                                 Collectors.toMap(c -> c.getName(), c -> c.getValue()));

                T body = responseHandler.handleResponse(response);

                return new Hresponse<T>(statusCode, headers, cookies, body);
            } catch (Exception e) {
                retryCount++;
                stopRetry = retryCount > request.getRetryMax();
                log.warn("请求[" + request + "]异常", e);
                if (!stopRetry) {
                    try {
                        Thread.sleep(request.getRetryInterval());
                    } catch (InterruptedException e1) {
                        e1.printStackTrace();
                    }
                    continue;
                } else {
                    throw e;
                }
            } finally {
                if (stopRetry) {
                    if (response != null)
                        response.close();
                    if (httpRequest != null)
                        httpRequest.reset();
                }
                if (clearCredentialTag)
                    ThreadLocalSocksProxyAuthenticator.INSTANCE.clearCredentials();
            }
        }
        return null;
    }

    /**
     * 请求文本.可以作为调用API或者加载网页
     *
     * @param request
     * @return
     * @throws Exception
     */
    public Hresponse<String> requestText(Hrequest request) throws Exception {
        return request(request, (org.apache.http.HttpResponse response) -> {
            HttpEntity entity = response.getEntity();
            if (entity == null)
                return null;
            String charset = null;
            // 优先选用contentType解析
            ContentType contentType = ContentType.get(entity);
            if (contentType != null) {
                Charset chars = contentType.getCharset();
                if (chars != null) {
                    charset = chars.name();
                }
            }

            if (charset == null) {// 最后选用请求默认的字符编码
                charset = request.getResCharset();
            }
            return EntityUtils.toString(entity, charset);
        });
    }

    /**
     * 下载二进制数据.
     *
     * @param request
     * @return
     * @throws Exception
     */
    public Hresponse<byte[]> requestByteArray(Hrequest request) throws Exception {
        return request(request, (org.apache.http.HttpResponse response) -> {
            HttpEntity entity = response.getEntity();
            return entity == null ? null : EntityUtils.toByteArray(entity);
        });
    }

    /**
     * 下载文件.该方法会在临时目录里生成一个临时文件
     *
     * @param request
     * @return
     * @throws Exception
     */
    public Hresponse<File> requestFile(final Hrequest request) throws Exception {
        final File output = File.createTempFile("httputil_", ".temp");
        return requestFile(request, output);
    }

    /**
     * 下载文件.
     *
     * @param request
     * @param outputFile
     * @return
     * @throws Exception
     */
    public Hresponse<File> requestFile(final Hrequest request, File outputFile) throws Exception {
        if (outputFile.exists())
            outputFile.delete();
        return request(request, (org.apache.http.HttpResponse response) -> {
            HttpEntity entity = response.getEntity();
            if (entity == null)
                return null;
            try (OutputStream os = new BufferedOutputStream(new FileOutputStream(outputFile), 8192)) {
                entity.writeTo(os);
            }
            return outputFile;
        });
    }
}
